Skip to content

GITHUB#14399: decide index-sort early termination per segment in TopFieldCollector - #16434

Open
serhiy-bzhezytskyy wants to merge 2 commits into
apache:mainfrom
serhiy-bzhezytskyy:GITHUB-14399-topfieldcollector-per-leaf-indexsort
Open

GITHUB#14399: decide index-sort early termination per segment in TopFieldCollector#16434
serhiy-bzhezytskyy wants to merge 2 commits into
apache:mainfrom
serhiy-bzhezytskyy:GITHUB-14399-topfieldcollector-per-leaf-indexsort

Conversation

@serhiy-bzhezytskyy

Copy link
Copy Markdown

Fixes #14399.

TopFieldCollector decided whether the search sort is a prefix of the index sort by inspecting only the first segment, then cached that decision for the whole search:

// as all segments are sorted in the same way, enough to check only the 1st segment for indexSort
if (searchSortPartOfIndexSort == null) {
  final Sort indexSort = context.reader().getMetaData().sort();
  searchSortPartOfIndexSort = canEarlyTerminate(sort, indexSort);
  ...
}

The premise holds for a single index — IndexWriter enforces one index sort — but not for a MultiReader, which can combine indexes sorted differently. The cached decision is then wrong for later segments, and early termination drops documents that should have ranked first.

This is a wrong-results bug, not only a missed optimisation. The added test builds a MultiReader over two indexes sorted in opposite directions (ndv ascending and ndv descending) and searches with ndv ascending. The competitive document sits at the end of the second segment's docid order, so the decision cached from the first segment early-terminates the second one and discards it. Without the fix:

AssertionError: the smallest value (1, trailing docid of leaf B) must win expected:<1> but was:<50>

The fix

Decide it per segment, inside TopFieldLeafCollector, rather than caching across leaves.

That change requires one adjustment: the old code called disableSkipping() on the shared FieldComparator, which is the wrong scope once the decision is per segment. disableSkipping() is now a default method on LeafFieldComparator and is called on that segment's leaf comparators; NumericComparator and TermOrdValComparator override it to drop their competitive iterator. This is close to what @jpountz suggested on the issue, except that the method moves to LeafFieldComparator rather than being removed from FieldComparator — the leaf is the object whose lifetime matches the decision.

Verification

  • The new test fails on unmodified main with the assertion above, and passes with the fix. Checked both ways rather than only the passing direction.
  • TestTopFieldCollector*, TestSortOptimization, TestSearchAfter and the comparators package pass.
  • ./gradlew check -x test is green.

…ieldCollector

TopFieldCollector cached whether the search sort is a prefix of the index sort
after inspecting only the first segment. IndexWriter enforces a single index
sort per index, but a MultiReader can combine indexes with different index
sorts, so the cached decision is wrong for later segments and can drop results
that should rank first.

Decide this per segment in each TopFieldLeafCollector instead of caching it.
disableSkipping() is added as a default method on LeafFieldComparator and is
now called on the per-segment leaf comparators (NumericComparator and
TermOrdValComparator drop their competitive iterator) rather than on the shared
FieldComparator.

Adds a test over a MultiReader whose two indexes are sorted in opposite
directions; it fails before the fix (returns a non-competitive value) and passes
after.

Signed-off-by: Serhiy Bzhezytskyy <me@serhiy-bzhezytskyy.com>

@romseygeek romseygeek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great, thanks @serhiy-bzhezytskyy!

// Whether the search sort is a prefix of the index sort is decided per segment: a MultiReader
// may combine segments with different index sorts, so this cannot be cached across leaves
// (GITHUB#14399).
final Sort indexSort = context.reader().getMetaData().sort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to use Sort.getPrimarySortField() here but I think that's going to end up being fairly complex in its interaction with sort prefixes, so we can leave that for a follow-up.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on leaving it for a follow-up. canEarlyTerminate compares the full prefix, so a primary-field shortcut would have to keep the same answer for multi-field sorts — worth its own change with its own tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured it since: substituting the shortcut into canEarlyTerminateOnPrefix returns a wrong result — an index sorted (a, c) searched by (a, b) gives the wrong top hit, because documents with equal a are ordered by c so their b order is arbitrary. testCanEarlyTerminateOnPrefix already asserts false for that shape. So the follow-up isn't needed here — the full prefix comparison is load-bearing.

// calls disableSkipping().
Directory dirA = newDirectory();
IndexWriterConfig iwcA = newIndexWriterConfig().setIndexSort(ascSort);
iwcA.setMergeScheduler(new SerialMergeScheduler());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to set the merge scheduler if we're force-merging down to a single segment?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No — removed. forceMerge(1) is synchronous so it makes no difference: 50 randomized iterations pass without it, and it still fails on unmodified main.

forceMerge(1) is synchronous, so the scheduler makes no difference here. Removed
after checking both directions: 50 randomized iterations pass without it, and it
still fails on unmodified main with 'the smallest value (1, trailing docid of leaf
B) must win expected:<1> but was:<50>'.

The import stays: the pre-existing test at line 82 uses it.

Signed-off-by: Serhiy Bzhezytskyy <me@serhiy-bzhezytskyy.com>
@romseygeek

Copy link
Copy Markdown
Contributor

This is backwards-compatible, I think? So we can move the CHANGES entry to 10.6 and I will backport after merging.

@serhiy-bzhezytskyy

Copy link
Copy Markdown
Author

Yes — additive only. LeafFieldComparator#disableSkipping is a default method, and both existing disableSkipping implementations (FieldComparator and NumericComparator) are untouched; the new overrides sit on the leaf comparators alongside them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TopFieldCollector mistakenly assumes that all leaves share the same index sort

3 participants